Skip to content

Resolve tag calls at compile time instead of through the metaclass - #16134

Open
codeconsole wants to merge 74 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x
Open

Resolve tag calls at compile time instead of through the metaclass#16134
codeconsole wants to merge 74 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Tag libraries are described as they are compiled, and that description resolves tag calls in code compiled afterwards. A call whose namespace and tag are known becomes a direct invocation instead of being dispatched through the metaclass, and nothing is installed onto a metaclass to make dispatch work.

Defining tags

class GreetingTagLib {
    static namespace = 'greet'

    def hello(Map attrs) {
        out << "Hello ${attrs.name}"
    }

    def wrapped(Map attrs, Closure body) {
        out << '<div>' << body() << '</div>'
    }
}

Calling tags

In a tag library or a controller:

class BookController {
    def index() {
        String markup = g.createLink(controller: 'book')   // compiled into a direct invocation
        String other  = greet.hello(name: 'Grails')        // likewise
        String third  = createLink(controller: 'book')     // likewise, when nothing else answers to the name
    }
}

A call that names its namespace is compiled the same way inside a closure — a tag body, a withFormat block, anything taking a block — as outside one, as is one in a constructor or a field initialiser. A call written without a namespace inside a closure is not: a closure is handed a delegate when it runs, and a name the delegate answers to is the delegate's rather than a tag's. request.withFormat { form multipartForm { } } is the case that settles it — form there is a format in a DSL, not the g:form tag.

The tag is still selected by name when the call runs, through the same lookup dynamic dispatch uses. A tag library that overrides another, one registered while the application is running, and the order tag libraries are registered in all decide the outcome exactly as before. Nothing is bound to a particular tag library class, so a tag declared by more than one of them, and a tag declared as a Closure field, are compiled the same way.

A call written without a namespace is not compiled unless the build asks for it with grails { compileStatic { unqualifiedTagCalls = true } }. Whether a bare name is a tag depends on what else answers to it, and not all of that is visible when compiling — a method Groovy gives every object, a delegate an enclosing closure is handed, an overload the tag library also declares — so by default such a call is dispatched exactly as before.

The attributes and body are passed straight through where the call says what they are; where it does not — a map held in a variable, a single value the tag reads under its own name — the arguments are forwarded as written and adapted by the same rules dynamic dispatch applies.

Tags in pages

A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not known when the page compiles. A page therefore keeps resolving its tags as it always has, unless it declares compileStatic:

<%@ page compileStatic="true" %>
${g.createLink(controller: 'book')}   <%-- compiled into a direct invocation --%>

Declaring it reserves the namespace names for tag libraries there. grails.views.gsp.compileStatic applies it to every page. A tag written as markup, <g:createLink controller="book"/>, already compiles into a direct call and is unchanged.

Checking tags

By default nothing is reported: a tag no compiled tag library declares is left to resolve at runtime, because a namespace can legitimately hold tag libraries carrying no description. An application whose tag libraries are all described can ask for an error instead:

grails {
    compileStatic {
        strictTags = true
        dynamicTagNamespaces = ['legacy']   // namespaces filled in while the application runs
    }
}

Strict checking applies to the namespaces this project's own tag libraries declare, which are the only ones whose contents are knowable when compiling. Every other namespace is left alone, g included: a plugin built before descriptors existed contributes tags to it without one, so a missing tag there is as likely to be a plugin's as a mistake.

dynamicTagNamespaces turns compile-time resolution off for a namespace completely — calls into it are never rewritten, never reported, and dispatched exactly as before.

Strict checking applies where the source says a call is a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, and a namespaced expression in a page is checked only where the page declares compileStatic.

Deprecation

Defining a tag as a Closure field warns at compile time. It still works and is called the same way; the form is deprecated because a closure carries no signature, so nothing about the call can be checked:

// Deprecated
Closure hello = { Map attrs -> out << "Hello ${attrs.name}" }

// Preferred
def hello(Map attrs) { out << "Hello ${attrs.name}" }

Why

Profiling a running application attributed roughly 25% of samples on a tag-heavy page to reflective and metaclass tag dispatch, and about 10% to ExpandoMetaClass read-lock contention.

Every caller used to mutate its own ExpandoMetaClass the first time it used a tag; every namespace dispatcher was built with a metaclass carrying a method per tag; and plugin bootstrap installed every tag onto every tag library. None of that remains.

Measured on a page performing 400 tag invocations, 8 concurrent, 105k warmup requests, same publish flow both sides:

ms/req
8.0.x 0.5213
this branch 0.4699

Compiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:

per tag call
expression in a compile-static page −66%
call written inside a tag library −33%

Where the description comes from

Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.

generateTagLibraryIndex runs before compilation, so a call to a tag the project itself declares resolves as it compiles. Reading from source it cannot describe everything — a tag library referring to a type written in Java, or generated by the build, is left out — so what it missed is recorded, and nothing in an incompletely described namespace is ever reported. It is never packaged.

packageTagLibraryIndex runs afterwards with the project's own classes on the classpath, where every tag library resolves. That index is the one pages compile against, the one packaged, and the one a project depending on this one reads. Each run replaces it, so a renamed or deleted tag library cannot survive.

A build that does not write the index — a plain groovyc, or a build without the Grails Gradle plugin — has each tag library annotated @TagLib describe itself as it compiles. That fallback does not reach a tag library declared by convention: an unannotated class under grails-app/taglib is recognised as an artefact later in the compilation than the descriptor is written, so without the Gradle plugin it contributes none. A tag with no description is dispatched dynamically, so nothing breaks; it simply does not take the faster path.

What is not rewritten

  • a namespace no compiled tag library declares, which is what keeps a tag library registered at runtime working
  • a namespace the build declared in dynamicTagNamespaces
  • a name something else in scope answers to — a local, parameter, field or getter called g is that thing
  • any call written without a namespace, unless the build sets unqualifiedTagCalls
  • an unqualified call in a page, and any expression in a page that has not declared compileStatic
  • a name a page puts into its own binding with <g:set>
  • an unqualified call inside a closure, which a delegate given to the closure at runtime may answer to
  • an unqualified call to a name Groovy already answers to — with, each, print and the rest of DefaultGroovyMethods, plus any extension module on the compiling classpath. Those are real methods on every receiver, so a tag of the same name must not capture the call
  • a controller declared with @Artefact('Controller') outside grails-app/controllers, which gains the ability to call tags later in the compilation than the rewriting runs

Limitations

  • A model attribute named after a namespace stops winning in a compileStatic page. That is what declaring it means there. A page that has not declared it is unaffected.
  • A method added to a controller or tag library at runtime, through doWithDynamicMethods, loses to a tag of the same name when the call is written without a namespace. Declare the method on the class, name the namespace in dynamicTagNamespaces, or call the tag with its namespace.
  • Unit testing support still installs tag methods onto metaclasses, deliberately: tests call tag methods directly, and the installed methods substitute an empty body for a missing one, so tagLib.someTag(attrs, null) works. A running application does not depend on this.
  • The end-to-end figure comes from one machine that showed thermal variance during the run; the per-call figures are in-process renders excluding the HTTP stack. Treat both as indicative of direction, not precise.
  • Scope within a method body is not tracked when deciding whether an unqualified name is claimed by a local. A name declared anywhere in the body counts throughout it, which can leave a call dispatched dynamically but never sends one somewhere else.
  • Extracting the discovery rules also changes runtime tag discovery, which is what DefaultGrailsTagLibClass is built from. Three differences from the code it replaces: equals/hashCode/toString and the GroovyObject members are excluded by name rather than by full signature; a zero-argument is* method is an accessor regardless of return type; and a name containing $ is excluded. None is reachable by a tag that would otherwise have been discovered — the shape check rejects all three anyway — and each is pinned in TagDiscoveryRulesSpec through both the tree and the compiled class.
  • A resolved call reports an unregistered tag as GrailsTagException rather than MissingMethodException. This arises where the index knows a tag but the running application has not registered it — a plugin excluded, a tag library in nonEnhancedTagLibClasses, a unit test mocking only some. Code catching MissingMethodException around a tag call, or probing with respondsTo, is affected. A call into an undescribed namespace still reports MissingMethodException.
  • A self-written descriptor is never removed. Where no build writes the index, renaming or deleting a tag library leaves its description behind until the build directory is cleaned. Builds using the Gradle plugin rewrite the index each run and are unaffected.
  • A tag's implementation is not recorded. The descriptor holds tag names only; a closure tag and a method tag are dispatched identically, by name, so nothing needed the distinction.

The closure form is deprecated and carries no callable signature, so a
tag defined that way cannot be resolved when a page is compiled. This
was the last closure-based tag remaining in the repository.
Discovering which tags exist required loading every tag library and
reflecting over it, which is only possible once the application is
running. A GSP therefore had no way to know at compile time whether a
tag call would resolve.

The TagLib AST transformation now records each tag library's namespace
and tag names as it is compiled, writing one descriptor per class under
META-INF/grails/taglibs along with a manifest naming them. Descriptors
are per class so that tag libraries packaged in separate jars merge on
the classpath with no build step combining them, in the manner of
META-INF/services entries.

Deriving tag names from the AST has to agree exactly with the runtime
rules in TagMethodInvoker, since a tag recorded in the index but
rejected at runtime would resolve when a page is compiled and then fail
when it renders. The framework method exclusions are shared rather than
duplicated, and TagLibraryIndexAgreementSpec asserts the two views
match for every framework tag library.

Two cases the AST view has to account for: trait application generates
super-accessor bridges that are synthetic at runtime but not marked so
at canonicalization, and parameters with default values expand into
overloads that reflection sees but the declaration does not show.
The type checking extension answered every unresolved tag call with
makeDynamic, so compileStatic on a GSP verified model fields and left
tag calls exactly as dynamic as they were without it.

Tag calls are now checked against the tag library index. A call into a
namespace backed by a compiled tag library must name a tag that library
declares, and a misspelling is reported when the page is compiled
rather than surfacing as a missing method when it renders. Namespaces
the index does not know, as a tag library registered at runtime or
supplied by a separately compiled plugin would be, keep resolving
dynamically.

Namespaces contributed by compiled tag libraries no longer have to be
declared through the taglibs directive, because the index already
states which tags they hold.
Dispatching a tag read Method.getParameters() on every invocation to
work out which parameter takes the attribute map, which takes the body,
and which are bound from named attributes. That allocates a fresh
Parameter array and materialises reflection metadata each time, and it
showed up directly in profiles of tag-heavy pages, yet the answer is
fixed for a given method.

The classification is now computed once, when the tag library class is
first seen, and held alongside the method. Invocation walks the
precomputed plan instead of re-reading reflection metadata, and the
access check is suppressed once rather than paid per call.

Also corrects two disagreements between the compile-time index and
runtime dispatch that the framework tag libraries did not exercise:
@tag and @NotATag override the conventional signature rule at runtime
and now do so when scanning the AST, and an attributes parameter has to
be assignable to Map, so an untyped parameter is not a dispatchable tag
and is no longer recorded as one. IndexEdgeCaseTagLib covers both
directions.
The index is written per tag library class specifically so that
libraries packaged in separate jars combine on the classpath without a
build step merging them. That is the central claim of the format and
was previously only exercised indirectly, through tag libraries that
all happened to live in one module.

Builds classpaths out of temporary jars and asserts that two jars
contributing to one namespace merge, that distinct namespaces stay
distinct, that an empty classpath yields an empty index rather than
failing, and that a malformed descriptor leaves its tags unknown so
they fall back to dynamic resolution.
A design review found the compile-time index and runtime dispatch
disagreeing in ways the framework tag libraries never exercise. Each
would let a page compile and then fail as it renders.

An attributes parameter is only recognised at runtime when it is named
"attrs", unless the class was compiled without parameter names, in
which case any name is accepted. The scanner checked only the type, so
a tag written as foo(Map options) was recorded but is not dispatchable.
Whether names are retained is read from the compiler configuration and
the same rule applied, with the body parameter treated the same way.

TagMethodInvoker scans declared methods, so a tag inherited from a base
class is not dispatchable. The scanner walked inherited methods too and
is now restricted to declarations on the tag library itself. Trait
methods are woven as declarations and remain visible.

A namespace is read at runtime through the class hierarchy and after
its initialiser has run. The scanner looked only at the class itself
and treated anything other than a constant as the default namespace,
filing those tags under "g". It now walks the hierarchy, and when the
namespace cannot be known without running the code the tag library is
left out of the index rather than filed under a guess.

An unrecognised tag is now a warning rather than a compilation error.
The index describes the tag libraries compiled before a page, so a tag
added without rebuilding its library, or a library registered at
runtime, would otherwise fail a build whose pages are correct. Setting
grails.views.gsp.strictTagChecking restores the error.
When more than one tag library declares the same namespace and tag, the
one registered last wins, and registration order comes from artefact
scanning rather than from the classpath. TagPrecedenceSpec pins that
down: the winner flips purely with registration order and carries no
inherent ranking, and returnObjectForTags follows the winner rather
than accumulating.

The index cannot reproduce that ordering, so it no longer tries. A tag
declared by two tag libraries is recorded as ambiguous and is not
resolved, which leaves the choice where it is actually made. Resolving
it here would risk compiling against one implementation and dispatching
to another. The same tag library reaching the classpath twice, as a
duplicated dependency does, names one implementation and stays
resolvable.

Descriptors also carry the format version they were written with, and
one written by a different version is ignored rather than read under
rules that may since have changed.
Whether a method is a tag was decided in two places: by reflection when
an application registers its tag libraries, and over the syntax tree
when the tag library index is written. Keeping the two in step was left
to a test, and they had already drifted apart three times.

The rules now live in TagDiscoveryRules, over a TagMethodView that a
compiled method and a method being compiled each adapt to. The two
sources differ in only two respects, both confined to their adapters:
parameter defaults have already become overloads by the time a class is
reflected on, and whether parameter names survive into the class file
is a property of the compilation rather than of the method.

TagDiscoveryRulesSpec compiles one matrix of method shapes and
classifies each of them twice, from the tree and from the resulting
class, asserting the two agree as well as asserting the expected
answer. It covers the shapes that caused the earlier drift: a Map
parameter not named attrs, an untyped parameter, @tag and @NotATag, a
framework trait name, and a defaulted trailing parameter.
The index was written as each tag library compiled, which left it
unable to describe the source set as a whole. A renamed or deleted tag
library kept its descriptor, and the manifest naming it, until the
build directory was cleaned, so the index went on describing tags that
no longer existed.

TagLibraryIndexGenerator now writes it for a whole source directory at
once, and clears what was there first, so what it describes is what
exists. Sources are parsed only as far as the syntax tree, never
loaded or executed, which is covered by a tag library whose static
initialiser would throw if it ran. Regenerating unchanged sources
produces a byte-identical index.

The generateTagLibraryIndex Gradle task runs it, before page
compilation and ahead of the artifact being packaged, so a project
depending on this one can resolve its tags. The generator reads source
rather than classes, so its classpath is the compile classpath alone:
including this project's own output made it wait for the compilation it
exists to precede, which showed up as a circular dependency through
compileAstGroovy. Two tests hold that ordering in place.

The AST transformation keeps writing descriptors, which covers tag
libraries compiled outside this task.
Registering a tag library asked the class what tags it declares, which
walks its metaclass properties, reflects over its declared methods and
scans its fields. That happens for every tag library as an application
starts, and the answer was already worked out when the tag library was
compiled.

Registration now prefers the tags recorded in the index, and discovers
them from the class only when there is no record. That keeps working
unchanged for a plugin built before the index existed, for a tag
library registered while an application is being developed, and for
one registered by a test.

A tag declared by more than one tag library is deliberately absent from
the index, so a tag library holding such a tag falls back to discovery
rather than registering an incomplete set.
Resolving a tag installed it onto the caller's metaclass so that later
calls bypassed methodMissing, and every namespace dispatcher was built
with its own ExpandoMetaClass carrying a method for each tag in the
namespace. Tag dispatch was therefore a read of an initialised
ExpandoMetaClass, guarded by a read-write lock that profiles of
concurrent rendering showed to be the largest single contended cost,
and every caller mutated its own metaclass the first time it used a
tag.

Both now dispatch through the tag library lookup, which is a map read.

Removing the installed methods is not simply removing a cache: they
carried overloads that adapted a CharSequence body into a closure and
routed the call through the output capture protocol. Dispatching
straight at the tag library skipped that and broke a tag called with a
string body. The dynamic path therefore goes through
methodMissingForTagLib, which already does both, with the flag that
installs the metaclass methods turned off.

NoMetaClassMutationSpec holds the property that resolving a tag writes
to no metaclass.
Now that the index is generated from source before anything resolving
tag calls is compiled, it describes the tag libraries of this project
as well as those of its dependencies, so a tag it cannot find in a
namespace it knows is a misspelling rather than a gap in what it has
seen. Those are reported as compilation errors.

A namespace with no compiled tag library is still left to runtime
resolution, as a tag library registered while developing or supplied by
a plugin built before the index existed would be, and a tag declared by
two tag libraries stays ambiguous and unresolved. Setting
grails.views.gsp.strictTagChecking to false turns the error back into a
warning.

Generating the index no longer fails when one tag library cannot be
resolved ahead of compilation. FormFieldsTagLib refers to services in
its own project, which by design are not on the classpath the generator
runs against, and that took the whole index down with it. Sources that
fail are parsed individually and those that still fail are named and
skipped, leaving them to be described by the compiler as they are
built.
Calling a tag reaches the tag library through invokeMethod, which
leaves a dynamic call site in the caller's bytecode even when that
caller is statically compiled. Once a tag has been resolved against the
index there is nothing left to decide beyond which bean holds it, so
the call can be an ordinary method call.

CompiledTagInvocation is that call. It takes the namespace and name as
arguments and ends at TagOutput.captureTagOutput, which is where the
dynamic path ends too, so attribute and body handling, output capture,
encoding and return-object behaviour are the same either way.
TagLibNamespaceMethodDispatcher, which is how a statically compiled
page reaches a tag, now goes through it.

This is the target a rewritten call site needs. Rewriting the call
sites themselves is not part of this commit.
Every tag library had every tag in every namespace installed onto its
metaclass as it was constructed, and again for the whole application at
plugin bootstrap, so that a tag library calling another tag found a
method rather than falling through to methodMissing. A namespace
resolved through propertyMissing was installed as a property too.

None of that is needed now that tags are resolved through the tag
library lookup and invoked through CompiledTagInvocation, so it is
gone. Registering a tag library with the lookup is all bootstrap does.

TagLibraryMetaUtils is deprecated. What remains of it is the dynamic
dispatch a tag library registered at runtime still relies on, reached
with metaclass installation switched off.

The compile-time warning for a closure-based tag now says what the
consequence is, that calls to it stay dynamic because it cannot be
resolved when a page is compiled, and shows the method form to use
instead.
Writing g.link(controller: 'book') reaches the tag library through
propertyMissing to find the namespace and invokeMethod to find the tag,
which leaves a dynamic call site in the bytecode of a tag library even
when it is statically compiled. Both names are fixed in the source and
the index says whether that tag exists, so the call is replaced with a
call to CompiledTagInvocation.

Only calls whose shape is evident from the source are rewritten: a tag
takes attributes, a body, both or neither, written as literals. A call
whose attributes are assembled at runtime, a namespace no compiled tag
library declares, a tag declared by more than one of them, and a
namespace shadowed by a field of the same name are all left to resolve
as they did before.

CompiledTagCallRewriterSpec renders through each of those shapes, since
a rewrite that changed behaviour is the failure that matters.
Behaviour alone cannot show that anything was rewritten, because the
dynamic route produces the same output, so CompiledTagCallBytecodeSpec
compiles a tag library and looks for the invocation in the class file,
and for its absence where nothing should have been rewritten.
A review of the stack found the strict check and the explicit
invocation path each breaking cases the dynamic path handled.

An unrecognised tag is a warning again rather than an error. Knowing
that a namespace holds some compiled tag libraries is not knowing that
it holds all of them: a plugin built before the index existed
contributes tags to g without a descriptor, a tag library registered
while an application runs contributes more, and the index generator
skips a source it cannot resolve ahead of compilation. In each case the
namespace is known but incomplete, so a tag missing from it is not
necessarily a misspelling. Failing the build needs a namespace able to
state that it is complete, which the descriptors cannot yet do.
grails.views.gsp.strictTagChecking opts in to the error.

A tag declared by more than one tag library was reported as no such
tag. The index deliberately leaves it unresolved so that runtime
precedence decides, which the checker read as absent. It now asks
whether the tag is ambiguous before reporting it.

A tag body given as text threw a GroovyCastException. The dynamic path
accepted text through overloads that wrapped it in a closure, and the
explicit API narrowed the body to Closure, which a namespaced
dispatcher call with a string body could not satisfy. The API takes the
body as it is given and wraps text, as before.

Registering a tag library after startup, as reloading a changed class
during development and registering one from a test both do, uses the
descriptor supplied rather than the one recorded when the class was
compiled, which no longer describes what is being registered.
A tag library rewrote its own tag calls as it compiled, but a
controller can call tags as well. It gains that from the tag library
invoker trait rather than from being a tag library, so nothing rewrote
its calls and they stayed dynamic.

A global transformation now rewrites tag calls in any class carrying
that trait, which covers controllers without naming them and without a
second copy of the rules. It runs after trait injection, since whether
a class can call tags is only settled once its traits are applied, and
it does nothing at all when no compiled tag library is on the
classpath.

ControllerTagCallRewriteSpec compiles a class with the trait and one
without, and looks in the class files for the invocation, since a
rewritten call and a dynamic one produce the same output.
Describes how tag libraries are described when compiled and how that
resolves tag calls, what is compiled into a direct invocation and what
stays dynamic, how an unrecognised tag is reported and how to turn that
into an error, why a closure-based tag cannot be resolved, and where
the description is written and packaged.

Adds the corresponding what's new entry and an upgrade note covering
the two things an existing application notices: the warning for an
unrecognised tag, and the warning for a closure-based tag with the
method form to replace it.
The pre-compilation task scanned only grails-app/taglib, so a project
keeping tag libraries elsewhere had them described as they compiled
rather than beforehand, which is later than anything resolving them in
the same compilation needs.

The task now takes a collection of directories, defaulting to the one
it scanned before, and the generator can add to an index rather than
always replacing it, so several directories contribute to one index
instead of each erasing the last.
Three places were still installing methods onto metaclasses, so the
earlier claim that dispatching a tag writes to none of them was wider
than what had actually been done.

A page had methodMissing installed onto its metaclass as it compiled,
along with a method for every tag and a property for every namespace.
GroovyPage declares methodMissing itself now and already resolved a
namespace through getProperty, so a page reaches the same tags without
any of those writes.

The template namespace installed a method for each template name the
first time it was used. Rendering goes through the render tag either
way, so the name is resolved rather than installed.

The unit testing support keeps installing tag methods, deliberately.
Tests call tag methods directly, and the installed methods substitute
an empty body for a missing one, so tagLib.someTag(attrs, null) works.
Removing it broke twelve FormTagLibTests cases that rely on that
calling convention. A running application does not depend on it.

NoMetaClassMutationSpec now covers the template namespace and the page,
alongside the namespace dispatcher it already covered.
The index said only that a tag existed, which is enough to tell a
misspelling from a real tag but not enough to decide whether a call to
it can be bound. A tag defined as a Closure field carries no signature,
so a call to it cannot become a direct invocation, and nothing in the
index said which tags those were.

Each tag is now recorded with its kind, and a call is only compiled
into a direct invocation when the tag is a method. A closure-based tag
stays known, so it is never reported as a misspelling, and stays
dynamically dispatched. This showed up immediately: g.link is a Closure
field, so calls to it are correctly left alone.

The descriptor format is version 2 as a result. A descriptor written by
another version is ignored rather than read under the wrong rules, and
a kind that cannot be recognised is treated as the dynamic one so that
a newer descriptor can never cause a call to be bound wrongly.
A namespace is not declared anywhere: it is reached because nothing
else answers to the name. The rewriter took any receiver that was not
this or super as a namespace, checking only for a field of that name on
the class itself, so a local variable, a parameter, an inherited field
or a getter-only property called g had calls on it rewritten into tag
invocations. The object the author wrote was then never called, and the
code still compiled, which is the worst way for this to go wrong.

A receiver that resolves to anything - a local, a parameter, a field, a
property - is that thing, and the field and property checks now walk
the hierarchy and consider getters.

Rewriting is also confined to methods declared by the class being
transformed. getMethods() reaches inherited methods, whose bodies
belong to the class that declared them, so a subclass able to call tags
could otherwise change a superclass that cannot.

TagCallShadowingSpec covers a local, a parameter, a typed local, a
field, an inherited method, and the unshadowed case that must still be
rewritten.
The index a project generates from its own sources reached page
compilation and the packaged artifact, but not the compilation of its
own controllers and tag libraries. Those could resolve tags from
dependencies while a call to a tag declared in the same project stayed
dynamic, which is not what the documentation described.

The generated directory now joins the compile classpath, and
compileGroovy waits for it. It goes onto the classpath rather than into
the source set output, which would make the index wait for the
compilation it exists to precede.

The documentation is also narrowed to what is actually rewritten.
Expressions in a GSP page are checked against the descriptions but are
not rewritten: a page selects the tag by name as it renders, through
the namespace dispatcher, which no longer touches a metaclass but is
still a runtime choice. An unqualified call such as message(code: 'x')
is likewise left alone, since whether that name is a tag or a method of
the calling class is decided where it is called. The examples now use a
method-based tag, since the closure-based g.link they used is one of
the calls that is deliberately not rewritten.
Groovy reads a property from getX() and, when the return type is
boolean, from isX() as well. Only the first was checked, so a class
declaring boolean isG() had this.g treated as a tag library namespace
and calls on it rewritten, sending them to a tag library instead of the
property the author wrote.

Both forms now claim the name, with the isX form requiring a boolean
return type as Groovy does. TagCallShadowingSpec covers each getter
form and an inherited getter.

Also proves the resolution the previous commit exists to enable.
Generating an index from a tag library source, putting it on a compile
classpath and compiling a controller that calls that namespace shows
the call becoming an invocation, and shows it staying dynamic without
the index. The build wiring is asserted separately; what was missing
was evidence that the wiring is sufficient for the compiler to resolve
the call.
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.41021% with 239 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.9870%. Comparing base (d0c68dc) to head (86642ab).

Files with missing lines Patch % Lines
...s/gsp/taglib/compiler/CompiledTagCallRewriter.java 75.7143% 20 Missing and 31 partials ⚠️
...roovy/org/grails/taglib/index/TagLibraryIndex.java 76.3514% 20 Missing and 15 partials ⚠️
.../grails/taglib/index/TagLibraryIndexGenerator.java 78.5235% 18 Missing and 14 partials ⚠️
...lugin/views/gsp/GenerateTagLibraryIndexTask.groovy 46.6667% 14 Missing and 2 partials ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 79.7101% 9 Missing and 5 partials ⚠️
...org/grails/taglib/index/TagLibraryIndexWriter.java 80.0000% 5 Missing and 8 partials ⚠️
.../compiler/TagLibArtefactTypeAstTransformation.java 63.6364% 7 Missing and 5 partials ⚠️
...grails/gsp/taglib/compiler/LocalNameCollector.java 71.0526% 9 Missing and 2 partials ⚠️
...ails/gsp/taglib/compiler/PageBindingCollector.java 73.0769% 0 Missing and 7 partials ⚠️
...mpiler/traits/CompiledTagCallTransformation.groovy 66.6667% 2 Missing and 5 partials ⚠️
... and 11 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16134        +/-   ##
==================================================
+ Coverage     53.6425%   53.9870%   +0.3445%     
- Complexity      19783      20136       +353     
==================================================
  Files            2086       2103        +17     
  Lines           99630     100589       +959     
  Branches        17594      17810       +216     
==================================================
+ Hits            53444      54305       +861     
+ Misses          38542      38531        -11     
- Partials         7644       7753       +109     
Files with missing lines Coverage Δ
...iler/TagLibraryInvokerTypeCheckingExtension.groovy 59.4595% <ø> (ø)
...adle/plugin/core/GrailsCompileStaticOptions.groovy 100.0000% <100.0000%> (ø)
.../groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy 100.0000% <ø> (+9.0909%) ⬆️
...sp/compiler/GroovyPageTypeCheckingExtension.groovy 65.0794% <100.0000%> (+2.7843%) ⬆️
.../org/grails/core/gsp/DefaultGrailsTagLibClass.java 94.5946% <100.0000%> (+1.5713%) ⬆️
...y/org/grails/taglib/NamespacedTagDispatcher.groovy 100.0000% <100.0000%> (+12.5000%) ⬆️
...ails/taglib/TagLibNamespaceMethodDispatcher.groovy 76.4706% <100.0000%> (+5.8824%) ⬆️
...ails/taglib/TemplateNamespacedTagDispatcher.groovy 9.0909% <ø> (-6.2937%) ⬇️
.../org/grails/taglib/index/TagLibraryIndexEntry.java 100.0000% <100.0000%> (ø)
.../src/main/groovy/grails/artefact/TagLibrary.groovy 71.4286% <ø> (ø)
... and 23 more

... and 21 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Reading walks every jar on the classpath. A compiler that consulted the
index for each source file walked it once per file, while the one caller
that did cache it held the result in a static field, which carried one
project's tag libraries into the next compilation in the same Gradle
daemon and read them from the wrong class loader. It is read once per
class loader instead, which is once per compilation and no longer.

Asking what a single tag library declares is answered from its own
descriptor. It used to be answered by scanning every namespace and then
discarding the answer entirely if any namespace anywhere held a tag two
libraries declared, so one overridden tag left every tag library
undescribed.

A tag two libraries declare is now reported as known even though it
cannot say which of them will answer to it, so that it is never mistaken
for a misspelling, and the settings a build states about its tag
libraries are read alongside the descriptors.
Registration preferred the tags recorded when a tag library was compiled
over the tags the class has, to save discovering them by reflection as
the application starts. It saved nothing: the tag library class is
constructed before it is registered, and constructing it already reads
every tag by reflection and through the metaclass.

What it did add was a way for a descriptor left behind by an earlier
build to decide what a running application believes a tag library
declares. Reflection is authoritative at runtime; the index describes
what was true when the tag library was compiled and is used where that
is the question being asked.
@jdaugherty

Copy link
Copy Markdown
Contributor

I posted the AI review, I'm not sure I agree with splitting it. I think it found enough real issues that we can use this to iterate on the review. @codeconsole

An unqualified call to a DefaultGroovyMethods method - with, each, print
and the rest - reached that method directly and never went near
methodMissing. Rewriting it into a tag invocation because a tag library
happened to declare a tag of the same name silently sent the call
somewhere it was never written to go. grails-fields already declares
f:with, so the collision is not hypothetical.

Reserve every name the metaclass answers to for an arbitrary receiver,
which covers DefaultGroovyMethods and any extension module on the
compiling classpath. A call that names its namespace is unaffected.
…name

A page resolves an unqualified name through a real methodMissing rather
than one installed onto its metaclass, and the field it resolves against
is documented as null until the page is initialised. Reaching the lookup
regardless arrives at the same missing-method answer, but only because a
dynamic call on a null receiver yields no tag library; say it instead,
and pin the behaviour with a spec.
The rewriting has to run after the transforms that apply the traits a
class calls tags through, and did - but only because a transform that
declares no priority defaults to zero, which happened to put it last
among the globals. A transform added later with the same default would
have displaced it silently.

Give it a slot in GroovyTransformOrder, as every other Grails global
transform has, and pin the relationship to artefact trait injection.
…alls onto metaclasses

The guide claimed every tag library describes itself when no build writes
the index. That path is a local AST transform bound to @taglib, so it
reaches annotated tag libraries only; one declared by convention is
recognised as an artefact too late to describe itself. Say so, along with
the descriptors that path leaves behind when a tag library is renamed.

Deprecate the metaclass-installing methods individually rather than the
class that holds them: methodMissingForTagLib is the dynamic dispatch
path and is not going anywhere. Record the removed and no-op metaclass
API in the upgrade guide, along with the closure tag form tag libraries
are actually written in.

Also drop LocalNameCollector's use of a deprecated Groovy API, which the
build was reporting, and collect the two names it was missing - a catch
parameter and a closure's implicit it.
The descriptor format ships for the first time in this release, so
starting it at two makes the number mean nothing later. Nothing outside
these files reads the constant.

Also remove the javadoc left above its replacement on FRAMEWORK_METHOD_NAMES
and on findTags.
findTagNames duplicated findTags with the same two loops and the same
declaring-class guard, and getIncompleteNamespaces exposed a field the
isNamespaceComplete question already answers. Neither had a caller
anywhere, in main code or in a test.
A test source set builds its runtime classpath from the main output
rather than from the main runtime classpath, so the packaged index never
reached it: a page rendered by a test resolved its tags against an index
missing the application's own tag libraries. Add it to the test and
integrationTest runtimes, and assert it.

The generator now reads sources with the encoding the project compiles
with rather than always UTF-8, and the settings file is written through
the key constants rather than through literals that repeat them.

Generated AST nodes are built per call site instead of reusing the
process-wide THIS_EXPRESSION and NULL singletons, which carry node
metadata that a statically compiled class writes to.

Drop the developmentMode fields nothing reads - one of which the trait
materialised into every controller and tag library - and the empty
@PostConstruct that remained once tags stopped being installed onto
metaclasses.

Also cover the build with a configuration cache run, which the index
tasks turn out to survive, and pin the on-disk format on both sides of
the module boundary that has to restate it.
…re missing

Descriptor URLs are resolved against the manifest that names them rather
than searched for on the classpath, which turned one full classpath walk
per tag library into none.

Pin the three ways these discovery rules differ from the ones they
replaced - an Object member name, a non boolean is accessor, and a name
containing a dollar - through both the tree and the compiled class, so
the claim that the two views cannot drift is enforced for them too.

Document the tag call that a controller annotated outside
grails-app/controllers does not get compiled, with a spec pinning both
shapes, and the GrailsTagException a resolved call now reports for a tag
the runtime has not registered.

Cover grails-mail's text:newLine, which had no test before its signature
was changed here, and say why it was changed: it works either way, but a
closure tag now warns and the framework should not trip its own warning.

Drop getAmbiguousTagNames, which restates isAmbiguous, and the benchmark
spec that was gated off by an environment variable and so never ran.
…e review

Every tag library compiled into one directory adds itself to a manifest
they all share, unguarded. Writing 32 of them at once lost 29: each
writer put back a copy that had never seen the others. A lost entry is
silent - the descriptor is there, nothing names it, so the tag library is
never discovered. Guard the read-modify-write with a monitor for threads
of this JVM and a file lock for a second process, and prove it with a
spec that fails without either.

Pin what a class calling a tag through methodMissing now gets back: both
ways of declaring a tag capture output, where a method-declared one used
to return its own return value. Record that in the upgrade guide.

Say plainly when strictTags can be used - a namespace only some of its
jars described cannot be checked, which is the normal state of g - and
correct the claim that a tag's kind decides whether a call is resolved.
It does not; both forms are dispatched by name.
request.withFormat { form multipartForm { } } was compiled into a call to
the g:form tag. form there is a format in a DSL: a closure is given a
delegate when it runs, and a name the delegate answers to is the
delegate's, not a tag library's. Nothing about that is knowable when the
closure is compiled, so an unqualified call inside one is left alone. A
call naming its namespace is unaffected, which is how a tag body keeps
the faster path.

Restore TagLibrary.initializeTagLibrary. It does nothing now, but a trait
method is part of the binary contract - Groovy weaves a call to the
generated helper into every implementing class, so removing it raised
NoSuchMethodError for every tag library compiled against an earlier
release, asset-pipeline's among them.
An import left unused when the manifest read moved to a channel, a blank
line left by removing a method, a groovy.lang import group that should
not be separated from org.codehaus.groovy, and an import moved out of its
group when @PostConstruct was restored.
It compiled a file into a temporary grails-app/controllers directory and
relied on the artefact injector recognising it by location. That passed
on macOS and failed on Ubuntu and Windows, in every CI run and again on
a rerun, and I could not reproduce it locally in any configuration -
alone, with --rerun-tasks, or with the whole module suite.

The behaviour is not in doubt: every application under
grails-test-examples declares its controllers that way and has its tag
calls compiled. What is left here keys on the trait, which is what the
rewriting actually reads, and on the annotated case that the trait
reaches too late.
@codeconsole
codeconsole requested a review from jdaugherty August 16, 2026 22:52

@sbglasius sbglasius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the whole diff. This is a large, well-reasoned change and the inline rationale throughout made it unusually easy to follow. I traced the Gradle wiring and found no task-dependency cycle, and I checked the two AST/reflection agreement risks I was most worried about empirically — Groovy's default-parameter overloads are neither synthetic nor stripped of parameter names, so TagDiscoveryRules.hasInvocableTagShape's optional-arity logic does agree with what reflection sees at runtime. Argument order between the rewriter and CompiledTagInvocation matches for both the page and non-page overloads, invokeTag/<g:set> index positions are right, and the REWRITTEN_MARKER correctly stops the local @TagLib transform and the global one from double-reporting.

Five things I'd like your read on, left as inline comments. Two themes:

Index ↔ runtime agreement is the load-bearing invariant. TagLibraryIndexAgreementSpec and TagDiscoveryRulesSpec pin the method rules well, but closure-field discovery isn't covered by the agreement test — and that's where the first comment lands. An agreement case for closure fields, including inherited ones, would close the gap.

The dynamic fallback (methodMissingForTagLib) is now load-bearing where it wasn't. Three call sites were redirected into it, and its argument-shaping switch silently swallows anything outside the three canonical shapes. That was tolerable when it only backed g.foo(...); comments 3 and 4 are the same root cause at runtime and at compile time.

One thing I couldn't resolve either way, so I've left it out of the inline comments: the closure-delegate reasoning is applied to unqualified method names but not to the receiver. A namespaced call is rewritten inside a closure even though g could equally be answered by the closure's delegate. I couldn't construct a realistic case where it misfires — the plausible delegates (withFormat, markup builders) don't answer to namespace names — but it is the symmetric hole to the one you explicitly guard, and may be worth a sentence in the Limitations section.

tags.put(method.getName(), TagLibraryIndexEntry.Kind.METHOD);
}
}
for (FieldNode field : classNode.getFields()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findTags() scans classNode.getFields(), which is declared-only, but DefaultGrailsTagLibClass registers Closure-typed tags by walking the whole superclass chain (and the metaclass property list, which also includes inherited properties). So an inherited closure tag exists at runtime but is missing from the index.

abstract class BaseTagLib { Closure common = { attrs -> } }
class MyTagLib extends BaseTagLib { static namespace = 'my' }

<my:common/> renders, because DefaultGrailsTagLibClass's field loop walks getSuperclass(). But MyTagLib's descriptor is written with an empty tag list, so isKnown('my','common') is false while isNamespaceComplete('my') is true — and with strictTags = true a call to my.common(...) fails compilation with "No such tag [common] in namespace [my]" for a tag that works. Without strictTags it just silently stays on the dynamic path.

Note the method side is consistent (both sides are declared-only, and IndexEdgeCaseTagLib/BaseEdgeTagLib pins that) — it's only the closure-field branch that diverges.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Enumeration is now shared the way classification already was: a TagLibraryView over a syntax tree or a compiled class, and one walk in TagDiscoveryRules that both TagLibraryAstDiscovery and DefaultGrailsTagLibClass route through. Method tags are read from the declaring class, closure tags up the hierarchy, matching dispatch.

TagSetAgreementSpec asserts the two views produce the same set; the three inherited-closure rows fail without the walk.

Two smaller divergences went with it: the AST used equals on Closure where the runtime uses isAssignableFrom, and closure-vs-method precedence is now decided once rather than falling out of loop order.


List<File> roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList();
List<File> skipped = new ArrayList<>();
for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This iterates every ClassNode parse() produced, which includes the sources SourceRootClassNodeResolver pulled in from resolutionRoots — not just the ones under sourceDirs. Combined with the bare-name fallback in isTagLibrary() (getName().endsWith("TagLib"), line 378), classes that were never in sourceDirectories and are not tag library artefacts get descriptors written for them.

GroovyPagePlugin sets resolutionSourceRoots to every main Groovy source root, which in a Grails app is src/main/groovy, grails-app/services, grails-app/controllers and the rest. So a helper like src/main/groovy/com/acme/BaseTagLib.groovy, referenced as a superclass by a real tag library, gets added to the compilation unit by the resolver, matches endsWith("TagLib"), resolves to the default namespace g, and has every (Map)-shaped method recorded as a g tag.

Two outcomes, both unwanted: if one of those names collides with a real g tag, TagLibraryIndex.load() marks it ambiguous and every g.<thatTag>(...) call in the project silently stops being rewritten (pure perf loss, invisible); if it doesn't collide, g.<helper>(...) compiles into a direct CompiledTagInvocation call that throws GrailsTagException at runtime, and strictTags no longer reports it.

Restricting the loop to class nodes whose source file was in sourceDirs would fix it without touching the resolver.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the core of this — the loop does describe everything parse() returned, resolver-pulled sources included, and the bare-name fallback then files a src/main/groovy helper's (Map) methods under g. One correction on the first outcome, worth having before anyone fixes it: a collision does not stop rewriting. isKnown deliberately answers true for an ambiguous tag and the invocation binds by name at runtime, so the colliding case is benign — the harm is confined to the non-colliding phantom (compiles into an invocation that throws GrailsTagException at runtime) and to strictTags no longer reporting a real misspelling that matches a phantom name. Restricting the loop to sourceDirs is still the right fix; see the new comment on isTagLibrary for the abstract-class case that restriction does not cover.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Descriptors are written only for class nodes whose source file was in sourceDirectories; a collaborator the resolver added to read a type is no longer described. TagLibraryIndexGeneratorSpec covers a *TagLib-named helper under a resolution root, and fails without the filter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted on the collision — you are right that isKnown answers true for an ambiguous tag and the invocation binds by name, so that case is benign; the harm is the non-colliding phantom and the missed strictTags report. The sourceDirs restriction is in, and the abstract case is fixed separately on your other comment.

// ExpandoMetaClass the first time it used a tag, and made every later call pay the
// read lock guarding an initialised metaclass. The tag is dispatched through the
// lookup each time instead, which is a map read.
return TagLibraryMetaUtils.methodMissingForTagLib(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes dispatch semantics for a name that is both a tag and a plain overload. The old tagLibrary.invokeMethod(methodName, args) dispatched on the real argument list; methodMissingForTagLib takes the tag branch as soon as hasInvokableTagMethod(tagBean, name) is true, and only then reshapes the arguments through a switch that understands arity 0, arity 1, and arity 2-with-a-Map. Every other argument list falls through with attrs = [:] and body = null.

Given a tag library with both:

def format(Map attrs) { ... }             // a tag
def format(String value, String pattern) { ... }  // a plain helper

a controller calling format('2026-08-19', 'yyyy') previously reached tagLibrary.invokeMethod and ran the two-String overload. Now hasInvokableTagMethod is true, the case 2 branch sees args[0] is not a Map, and captureTagOutput invokes format([:]) — both arguments silently discarded, wrong output, no error.

(The respondsTo fallback further down only runs when the name isn't an invokable tag method, so it doesn't catch this.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, including the before: at the merge-base this line was tagLibrary.invokeMethod(methodName, args), which dispatched format('2026-08-19', 'yyyy') to the two-String overload on the bean. methodMissingForTagLib itself is unchanged by this PR — its tag branch and argument switch predate it — so the regression is purely that this call site now routes into it. Whatever the fix looks like, it needs to land in both halves: guarding only the rewriter (the sibling comment) leaves this runtime path discarding the same arguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. methodMissingForTagLib takes the tag branch only when the argument list is a shape a tag can be called with — none, one, or two whose first is a Map. Anything else falls through to the method lookup, which finds the overload.

TagLibraryInvokerDispatchSpec covers format(Map) beside format(String, String) called with two Strings; it runs the tag with no attributes without the fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Landed in both halves: methodMissingForTagLib only takes the tag branch for an argument list a tag can be called with, and the rewriting declines to compile a shape the invocation cannot account for. Specs cover the runtime path and the bytecode path separately.

invocationArgs.addExpression(transform(argument));
}
return new StaticMethodCallExpression(INVOCATION_TYPE,
page ? INVOKE_ARGUMENTS_IN_CONTEXT : INVOKE_ARGUMENTS, invocationArgs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the TagLibraryInvoker comment, but baked into bytecode. When attributesAndBody() can't classify the arguments, the rewrite emits invokeArguments/invokeArgumentsInContext, whose switch discards any argument list that isn't 0 args, 1 arg, or 2 args starting with a Map.

With the same format(Map attrs) / format(String, String) pair, an unqualified format('2026-08-19', 'yyyy') in another tag library has declaresMember('format') == false on the caller and isKnown('g','format') == true, so it's rewritten to CompiledTagInvocation.invokeArguments(lookup, 'g', 'format', '2026-08-19', 'yyyy'). arguments.length == 2, arguments[0] isn't a Map, so attrs stays emptyMap() and body stays null — the tag runs with no attributes, no exception, and no compile-time diagnostic. Before the rewrite this reached the real method via methodMissing.

The comment on invokeArgumentsInContext says it deliberately mirrors methodMissingForTagLib including "its treatment of argument lists that match none of them", which is faithful — but the shapes that previously never reached that code now do. Rejecting the rewrite when the argument list matches none of the known shapes would keep those calls on the old path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and then made narrower. The rewriting now declines to compile an argument shape the invocation cannot account for, so those calls stay on the dynamic path.

This was the fourth bug of the same family in this branch — DefaultGroovyMethods names, closure delegates, withFormat, and this — so the rule changed rather than gaining a fourth exclusion: rewriting an unqualified call is now off unless a build sets grails.compileStatic.unqualifiedTagCalls. Namespaced calls, markup tags and statically compiled page expressions are unaffected, which is where the measured benefit came from.

if (sourceSets == null) {
return
}
for (String name : TEST_SOURCE_SET_NAMES) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourceSets.findByName(name) is eager, and configureProject runs inside pluginManager.withPlugin('groovy') — early. A source set registered by a plugin applied later returns null and is skipped with no diagnostic, which is exactly the gap the method's own comment says it exists to close ("Without this a page rendered from a test resolves its tags against an index missing the application's own tag libraries").

integrationTest is the one at risk, since it's typically registered by grails-gradle's integration-test support rather than by the Java plugin. sourceSets.configureEach { if (it.name in TEST_SOURCE_SET_NAMES) ... } (or matching) would make the wiring order-independent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. sourceSets.matching { it.name in TEST_SOURCE_SET_NAMES }.configureEach { } instead of the eager lookup, so integrationTest is picked up whenever it is registered.

@sbglasius

Copy link
Copy Markdown
Contributor

I took the liberty to have my Claude review your code.

A Closure tag declared on a base class is registered at runtime, which
walks the superclass chain for Closure-typed fields, but was missing from
the index, which read declared fields only. The namespace still counted
as completely described, so under strictTags a call to a working tag
failed the build, and without it the call silently stayed dynamic.

The rules already had one statement of whether a method is a tag, so the
two sides could not disagree about that. They had two statements of which
members to ask about and how far up the hierarchy, which is where they
did disagree. Give enumeration the same treatment: a TagLibraryView over
a syntax tree or a compiled class, one walk in TagDiscoveryRules, and a
spec asserting the two views produce the same set.

The walk also settles two smaller differences the same way the runtime
does: a field typed as a subclass of Closure is a tag, and a name
declared both as a closure and as a method is the closure.
Three things sbglasius found, all where the compiled path and the dynamic
one disagreed about what a call means.

The generator described any class named *TagLib that the compilation
produced, which includes the collaborators the resolver adds to read a
type. A helper under src/main/groovy would be filed as a tag library of
the default namespace, making its methods g tags that either collide with
real ones, silently disabling rewriting for that name, or resolve to a
tag that does not exist when the call runs. Only the sources the
generator was pointed at are described now.

A tag takes attributes, a body, or both. Any other argument list was
reduced to a call with neither, silently dropping what was written, so a
name that is both a tag foo(Map) and a helper foo(String, String) ran the
tag with nothing where it used to reach the helper. Dynamic dispatch now
leaves such a call to the method lookup, and the rewriting declines to
compile a shape the invocation cannot account for.

Test source sets are matched as they are created rather than looked up on
the groovy plugin being applied, since integrationTest is registered
later and was being skipped without a word - the gap that wiring exists
to close.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the fix round

I checked out the updated branch and went through all twelve commits against each item from my last review. Every item is addressed: the reserved-name seeding, the per-site AST nodes, the declared transform order with its spec, the dispatch-semantics spec and upgrade note, the index API trims and format reset, the pinned constants on both sides of the module boundary, the configuration-cache and test-classpath coverage in the wiring spec, the encoding convention, the manifest locking (the 29-of-32 loss reproduction was worth having), and the docs. Verification was targeted rather than a full suite run: I re-read each fix in the code and reproduced locally where a claim needed it.

I also went through sbglasius's five comments and can confirm all five against the code. The two dispatch ones I verified against the merge-base as well — the old trait path really did reach a real overload through invokeMethod, and methodMissingForTagLib itself is unchanged by this PR, so the regression is purely which call sites now route into it. The source-set one holds as an ordering fragility: with the stock plugin order grails-app registers integrationTest before grails-gsp configures, so it bites when grails-gsp is applied first or without the app plugin — configureEach is still the right fix. One correction on the generator one is in a reply there.

Two new items below, both reproduced:

  1. Abstract classes are described by the generator but can never be registered at runtime — details on isTagLibrary.
  2. The :grails-taglib build prints a javac deprecation note again, introduced by the manifest-sibling resolution — details on resolveSibling.

}
}
}
return classNode.getName().endsWith(TAG_LIB_ARTEFACT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abstract classes are described, but can never be tag libraries at runtime.

Reproduced by running the generator over a temp grails-app/taglib:

// grails-app/taglib/demo/BaseTagLib.groovy
abstract class BaseTagLib {
    def common(Map attrs) { 'shared' }
}

// grails-app/taglib/demo/MyTagLib.groovy
@TagLib
class MyTagLib extends BaseTagLib { static namespace = 'my' }

produces demo.BaseTagLib.properties with namespace=g and tags=common:METHOD. At runtime that tag exists nowhere: ArtefactHandlerAdapter.isArtefactClass rejects abstract classes (allowAbstract is false and TagLibArtefactHandler does not set it), so BaseTagLib is never registered — and MyTagLib's method tags are declared-only, so common is not a tag of my either. Every g.common(...) in the project then compiles into a direct invocation that throws GrailsTagException when it runs, and under strictTags a real misspelling that happens to match a phantom name is no longer reported.

Restricting the loop to sourceDirs (sbglasius's comment above) does not cover this case: grails-app/taglib is a source dir, and an abstract base living there for its subclasses to share is exactly the shape IndexEdgeCaseTagLib's own BaseEdgeTagLib fixture has.

Suggest skipping abstract ClassNodes here, which is the same rule the runtime applies — and it covers traits and interfaces for free, since their class nodes are abstract too. The @TagLib-annotated abstract case in TagLibArtefactTypeAstTransformation.writeIndexEntry wants the same check. Worth a test asserting that an abstract base with a (Map)-shaped method produces no descriptor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Abstract class nodes are skipped in the generator and in TagLibArtefactTypeAstTransformation.writeIndexEntry, which covers traits and interfaces since their nodes are abstract too. TagLibraryIndexGeneratorSpec asserts an abstract base beside its subclasses produces no descriptor while the subclass is still described; it fails without the check.

*/
private static URL resolveSibling(URL manifest, String fileName) {
try {
return new URL(manifest, fileName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reintroduces the javac deprecation note the last round removed.

Every java.net.URL constructor is deprecated since JDK 20, so on the JDK 21 baseline ./gradlew :grails-taglib:compileGroovy now prints:

Note: .../org/grails/taglib/index/TagLibraryIndex.java uses or overrides a deprecated API.

The awkward part is that the constructor is the right tool here: URI.resolve cannot resolve a relative name against a jar: URI (it is opaque), and round-tripping the manifest URL through URI breaks on characters getResources does not encode. So rather than replacing it, suppress it deliberately — @SuppressWarnings("deprecation") on resolveSibling with a sentence saying why the constructor stays — so the module builds quietly without the reason getting lost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested — @SuppressWarnings("deprecation") on resolveSibling with the reason recorded: URI.resolve cannot resolve a relative name against an opaque jar: URI, so the constructor stays. The module compiles without the note.


List<File> roots = resolutionRoots != null ? resolutionRoots : Collections.emptyList();
List<File> skipped = new ArrayList<>();
for (ClassNode classNode : parse(sources, roots, parameterNamesRetained, encoding, skipped)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the core of this — the loop does describe everything parse() returned, resolver-pulled sources included, and the bare-name fallback then files a src/main/groovy helper's (Map) methods under g. One correction on the first outcome, worth having before anyone fixes it: a collision does not stop rewriting. isKnown deliberately answers true for an ambiguous tag and the invocation binds by name at runtime, so the colliding case is benign — the harm is confined to the non-colliding phantom (compiles into an invocation that throws GrailsTagException at runtime) and to strictTags no longer reporting a real misspelling that matches a phantom name. Restricting the loop to sourceDirs is still the right fix; see the new comment on isTagLibrary for the abstract-class case that restriction does not cover.

// ExpandoMetaClass the first time it used a tag, and made every later call pay the
// read lock guarding an initialised metaclass. The tag is dispatched through the
// lookup each time instead, which is a map read.
return TagLibraryMetaUtils.methodMissingForTagLib(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, including the before: at the merge-base this line was tagLibrary.invokeMethod(methodName, args), which dispatched format('2026-08-19', 'yyyy') to the two-String overload on the bean. methodMissingForTagLib itself is unchanged by this PR — its tag branch and argument switch predate it — so the regression is purely that this call site now routes into it. Whatever the fix looks like, it needs to land in both halves: guarding only the rewriter (the sibling comment) leaves this runtime path discarding the same arguments.

* @return whatever the tag produces
*/
public Object methodMissing(String name, Object args) {
return TagLibraryMetaUtils.methodMissingForTagLib(getMetaClass(), getClass(), gspTagLibraryLookup,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the guard — stating the contract beats leaning on how dynamic dispatch happens to treat a null receiver, and the reworded comment plus GroovyPageMethodMissingSpec now say exactly what it does.

* @param acceptsBody whether the tag can be called with a body
* @since 8.0.0
*/
public record TagLibraryIndexEntry(String namespace, String tagName, String tagLibraryClassName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep getTagNamesForClass and isClassDescribed — each pins an invariant nothing else states. lookup from the original list is in the same boat (still spec-only); given the agreement spec reads through it and it is the natural read API next to isAmbiguous, keeping all three is fine.

@jdaugherty

Copy link
Copy Markdown
Contributor

@codeconsole

A namespaced call names the tag library it means. A bare name is a tag
only when nothing nearer answers to it, and what answers to it is not
fully visible when compiling: a method Groovy gives every object, a
delegate an enclosing closure is handed when it runs, an overload the tag
library also declares. Each of those has been a bug in this branch, found
one at a time and fixed by adding another exclusion, which is a sign the
rule was wrong rather than that the exclusions were incomplete.

So unqualified rewriting is now off unless a build sets
grails.compileStatic.unqualifiedTagCalls. Namespaced calls, markup tags
and statically compiled page expressions are unaffected, which is where
the measured benefit came from. The exclusions stay: turning it on widens
which calls are considered, not which names may be captured.
Every other assertion of this compiles a source in isolation, which shows
the transform works but not that a project reaches it: the index has to
be generated, packaged, put on the compile classpath and read, and the
rewriting has to run after the trait that lets the class call tags.

The spec that did cover the convention path drove it by writing a source
into a temporary grails-app/controllers directory, and passed on macOS
while failing on Linux and Windows - recognising a controller by its
location depends on where the compilation happens, not on what is being
compiled. Reading the class file a real build produced has no such
dependence, so this answers the same question wherever CI runs it.
Kind was written into every descriptor as name:KIND, parsed back out and
exposed as isBindable, and nothing ever asked. A closure tag and a method
tag are dispatched the same way - by name, when the call runs - so no
decision turned on it, and the javadoc claiming it decided whether a call
could be resolved was simply wrong.

Dropping it takes the encoding out of the format, the enum and the
accessor out of the API, and the precedence rule out of the walk, which
now just collects names. FORMAT_VERSION is what makes this reversible:
the distinction can come back when something needs it.
Strict checking asked whether a tag was in the index, and the index holds
what the tag libraries on the classpath described. For a namespace this
project declares that is the whole answer. For any other it is not: a
plugin built before descriptors existed contributes tags to g without
one, as does one declaring its tag libraries by convention without the
GSP Gradle plugin, and a tag library registered at runtime contributes
more. Reporting a tag missing from such a namespace failed builds over
correct code, which made strictTags unusable for g - the namespace it
would matter most for.

The generator already knows which namespaces it described, so the task
records them beside the settings, which are not packaged, and reporting
is limited to those. strictTags now catches a misspelling of your own
tags in your own namespaces and never complains about a plugin's.
An abstract class kept beside the tag libraries that share it was
described, with its methods filed under the default namespace. Artefact
handling never registers one, and a subclass does not inherit its methods
as tags, so those tags existed nowhere: a call to one compiled into an
invocation that throws when it runs, and a misspelling matching such a
name stopped being reported. Skip abstract class nodes in the generator
and in the self-describing path, which covers traits and interfaces too.

Also suppress the URL constructor deprecation deliberately rather than
leave the note the last round removed: URI.resolve cannot resolve a
relative name against an opaque jar: URI, so the constructor stays.
new URL(URL, String) is deprecated since JDK 20, so compiling this module
printed a deprecation note. The constructor is nonetheless the tool that
works: a manifest inside a jar is addressed by an opaque jar: URI, which
URI.resolve cannot resolve a relative name against, and round-tripping the
URL through URI breaks on characters ClassLoader.getResources does not
encode.

Suppress it deliberately and record the reason, so the module compiles
quietly without the reason being lost with it.
…at/taglib-compile-time-index-8.0.x

# Conflicts:
#	grails-doc/src/en/guide/introduction/whatsNew.adoc
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@testlens-app

testlens-app Bot commented Aug 19, 2026

Copy link
Copy Markdown

🚨 TestLens detected 14 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1) > :grails-data-mongodb-core:test

Test Runs Flakiness
MongoTransactionSpec > test a REQUIRES_NEW inner transaction commits independently of a rolled back outer transaction 0% 🟢
MongoTransactionSpec > test a committed transaction persists all writes atomically 0% 🟢
MongoTransactionSpec > test a findOneAndDelete via the MongoEntity API participates in the transaction 0% 🟢
MongoTransactionSpec > test a per-transaction timeout is rejected rather than silently ignored 0% 🟢
MongoTransactionSpec > test a rolled back transaction discards a native Long id entity (id generation is non-transactional) 0% 🟢
MongoTransactionSpec > test a rolled back transaction discards all writes on the server 0% 🟢
MongoTransactionSpec > test native Long identifier generation works for entities committed in a transaction 0% 🟢
MongoTransactionSpec > test read-your-writes within an active transaction 0% 🟢
MongoTransactionSpec > test writes across multiple collections roll back together 0% 🟢

CI - Groovy Joint Validation Build / Build Grails with Groovy snapshot (shard 1) > :grails-test-examples-app1:test

Test Runs Flakiness
CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation 0% 🟢

CI / Functional Tests (Java 21, indy=false) > :grails-test-examples-app1:test

Test Runs Flakiness
CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation 0% 🟢

CI / Functional Tests (Java 21, indy=true) > :grails-test-examples-app1:test

Test Runs Flakiness
CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation 0% 🟢

CI / Functional Tests (Java 25, indy=false) > :grails-test-examples-app1:test

Test Runs Flakiness
CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation 0% 🟢

SiteMesh 2 Compatibility / SiteMesh 2 Functional Tests (Java 21, indy=false) > :grails-test-examples-app1:test

Test Runs Flakiness
CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation 0% 🟢

🏷️ Commit: 86642ab
▶️ Tests: 14 executed
🟡 Checks: 69/75 completed

Rerun Controls

Note

Checks are currently running using the configuration below.

Select tests to mute in this pull request:

🔲 CompiledTagCallSpec > a namespaced tag call in a convention controller is compiled into an invocation
🔲 MongoTransactionSpec > test a REQUIRES_NEW inner transaction commits independently of a rolled back outer transaction
🔲 MongoTransactionSpec > test a committed transaction persists all writes atomically
🔲 MongoTransactionSpec > test a findOneAndDelete via the MongoEntity API participates in the transaction
🔲 MongoTransactionSpec > test a per-transaction timeout is rejected rather than silently ignored
🔲 MongoTransactionSpec > test a rolled back transaction discards a native Long id entity (id generation is non-transactional)
🔲 MongoTransactionSpec > test a rolled back transaction discards all writes on the server
🔲 MongoTransactionSpec > test native Long identifier generation works for entities committed in a transaction
🔲 MongoTransactionSpec > test read-your-writes within an active transaction
🔲 MongoTransactionSpec > test writes across multiple collections roll back together

Reuse successful test results:

🔲 ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

☑️ Rerun jobs


Learn more about TestLens at testlens.app/docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants